--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 5419fabf6489a51da520956b5d5156cd8665f684
Parents : dff83e1
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-24T07:23:10-05:00
feat: improve WebSocket reconnection handling and CSRF token management.
Changes
9 files changed, 369 insertions(+), 6 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9c44e360..9f1b5f4e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -68,6 +68,7 @@ All notable changes to this project will be documented in this file.
### Fixed
+- Web Sync Messages after a backgrounded browser tab: recover stale WebSocket as a shell reconnect, refresh CSRF, and do not abort sync when request-path priming fails
- Desktop AppImage: main-process logs always append to the storage logs folder (meshchatx.log). Stdout is only used when a terminal is attached, with broken-pipe guards as a fallback, so background launches no longer raise write EPIPE dialogs
- Android: lxmfy packaging, flock soft-lock, splash/logo clipping, Landlock skipped on Android
- Android RNode BLE/USB via Chaquopy
diff --git a/meshchatx.rsm b/meshchatx.rsm
index bbcdff70..142f47ff 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 96eed2b0..45fa5161 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -635,6 +635,7 @@ import AppIdentitySwitchOverlay from "./layout/AppIdentitySwitchOverlay.vue";
import KeyboardShortcuts from "../js/KeyboardShortcuts";
import ElectronUtils from "../js/ElectronUtils";
import { postRequestPath } from "../js/reticulumPathfinding.js";
+import { fetchCsrfToken } from "../js/csrfToken.js";
import ToneGenerator from "../js/ToneGenerator";
import { listNavItems } from "../js/registries/navRegistry.js";
import { onWsEvent, offWsEvent } from "../js/registries/wsEventRegistry.js";
@@ -1322,6 +1323,11 @@ export default {
}
},
async resyncShellAfterWebsocketReconnect() {
+ try {
+ await fetchCsrfToken(window.api);
+ } catch {
+ // ignore
+ }
try {
await this.getAppInfo();
} catch {
@@ -1931,12 +1937,20 @@ export default {
try {
const preferredHash = this.config?.lxmf_preferred_propagation_node_destination_hash;
if (preferredHash) {
- await postRequestPath(window.api, preferredHash);
+ // Best-effort path priming. /sync also requests a path.
+ // Do not abort sync if this POST fails (stale CSRF / brief offline
+ // after a backgrounded web tab).
+ try {
+ await postRequestPath(window.api, preferredHash);
+ } catch {
+ // continue to sync
+ }
}
await window.api.get("/api/v1/lxmf/propagation-node/sync");
} catch (e) {
this.userInitiatedPropagationSync = false;
- const errorMessage = e.response?.data?.message ?? this.$t("app.sync_error_generic");
+ const errorMessage =
+ e.response?.data?.message ?? e.response?.data?.error ?? this.$t("app.sync_error_generic");
ToastUtils.error(errorMessage);
return;
}
diff --git a/meshchatx/src/frontend/js/WebSocketConnection.js b/meshchatx/src/frontend/js/WebSocketConnection.js
index f942608f..a3147093 100644
--- a/meshchatx/src/frontend/js/WebSocketConnection.js
+++ b/meshchatx/src/frontend/js/WebSocketConnection.js
@@ -247,6 +247,11 @@ class WebSocketConnection {
return;
}
if (this.ws) {
+ // Suppress the disconnect banner, but still tell the shell this is a
+ // reconnect so CSRF/config/status resync after background-tab stalls.
+ if (this._hadSuccessfulOpen) {
+ this._pendingReconnectUi = true;
+ }
this._isForcedReconnect = true;
try {
this.ws.close();
diff --git a/meshchatx/src/frontend/js/apiClient.js b/meshchatx/src/frontend/js/apiClient.js
index 46097534..ff9d9b93 100644
--- a/meshchatx/src/frontend/js/apiClient.js
+++ b/meshchatx/src/frontend/js/apiClient.js
@@ -2,7 +2,7 @@
* Axios-shaped HTTP helpers backed by fetch (same-origin API calls).
*/
-import { getCsrfToken } from "./csrfToken.js";
+import { fetchCsrfToken, getCsrfToken } from "./csrfToken.js";
export function isCancel(error) {
if (!error) return false;
@@ -64,13 +64,29 @@ async function readSuccessBody(response, responseType) {
return response.text();
}
+/**
+ * True when a 403 is a CSRF rejection (not a missing login session).
+ * @param {number} status
+ * @param {unknown} errData
+ * @returns {boolean}
+ */
+export function isCsrfRejection(status, errData) {
+ if (status !== 403) {
+ return false;
+ }
+ const text =
+ (errData && typeof errData === "object" && (errData.error || errData.message)) ||
+ (typeof errData === "string" ? errData : "");
+ return typeof text === "string" && /csrf/i.test(text);
+}
+
/**
* @param {{ onAuthError?: (err: Error & { response?: { status: number, data: unknown } }) => void }} options
*/
export function createApiClient(options = {}) {
const { onAuthError } = options;
- async function request(method, path, config = {}) {
+ async function request(method, path, config = {}, csrfRetry = false) {
const { params, data, signal, headers = {}, responseType } = config;
const url = buildUrl(path, params);
const hdrs = new Headers(headers);
@@ -111,8 +127,26 @@ export function createApiClient(options = {}) {
name: "HttpError",
response: { status: response.status, data: errData },
});
+
+ const mutating = method !== "GET" && method !== "HEAD" && path.startsWith("/api/");
+ if (mutating && !csrfRetry && isCsrfRejection(response.status, errData)) {
+ try {
+ await fetchCsrfToken({
+ get(csrfPath) {
+ return request("GET", csrfPath, {});
+ },
+ });
+ } catch {
+ // Fall through and surface the original CSRF error.
+ throw err;
+ }
+ return request(method, path, config, true);
+ }
+
if (onAuthError && (response.status === 401 || response.status === 403)) {
- onAuthError(err);
+ if (!isCsrfRejection(response.status, errData)) {
+ onAuthError(err);
+ }
}
throw err;
}
diff --git a/tests/frontend/AppPropagationSync.test.js b/tests/frontend/AppPropagationSync.test.js
index 773ddc08..61b8bc8d 100644
--- a/tests/frontend/AppPropagationSync.test.js
+++ b/tests/frontend/AppPropagationSync.test.js
@@ -226,4 +226,79 @@ describe("App propagation sync", () => {
expect(ToastUtils.error).toHaveBeenCalledWith("Sync error: No path to node");
expect(ToastUtils.success).not.toHaveBeenCalled();
});
+
+ it("still starts sync when request-path fails (stale CSRF / brief offline after background)", async () => {
+ // Oracle: request-path is best-effort priming. The /sync GET already
+ // requests a path server-side. A CSRF or network failure on the POST
+ // must not abort the user-initiated sync (common after a backgrounded tab).
+ axiosMock.post.mockRejectedValue(
+ Object.assign(new Error("HTTP 403"), {
+ response: { status: 403, data: { error: "Invalid or missing CSRF token" } },
+ })
+ );
+ let syncCalled = false;
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/lxmf/propagation-node/sync") {
+ syncCalled = true;
+ return Promise.resolve({ data: { message: "Sync is starting" } });
+ }
+ if (url === "/api/v1/lxmf/propagation-node/status") {
+ return Promise.resolve({
+ data: {
+ propagation_node_status: {
+ state: "complete",
+ progress: 100,
+ messages_received: 1,
+ messages_stored: 1,
+ delivery_confirmations: 0,
+ messages_hidden: 0,
+ },
+ },
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const ctx = makeSyncContext(axiosMock);
+ await App.methods.syncPropagationNode.call(ctx);
+ await vi.runOnlyPendingTimersAsync();
+
+ expect(syncCalled).toBe(true);
+ expect(ToastUtils.success).toHaveBeenCalled();
+ expect(ToastUtils.error).not.toHaveBeenCalled();
+ });
+
+ it("clears stuck userInitiatedPropagationSync when status returns idle after background", async () => {
+ // Oracle: after a backgrounded tab, chrome can still show a prior sync
+ // as running. A status poll that sees idle/complete must clear the flag
+ // so the next Sync Messages click starts a new sync instead of stop-confirm.
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/lxmf/propagation-node/status") {
+ return Promise.resolve({
+ data: {
+ propagation_node_status: {
+ state: "idle",
+ progress: 0,
+ messages_received: 0,
+ messages_stored: 0,
+ delivery_confirmations: 0,
+ messages_hidden: 0,
+ },
+ },
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const ctx = makeSyncContext(axiosMock);
+ ctx.userInitiatedPropagationSync = true;
+ ctx.propagationNodeStatus = { state: "path_requested", progress: 5 };
+ expect(ctx.isSyncingPropagationNode).toBe(true);
+
+ await App.methods.updatePropagationNodeStatus.call(ctx);
+
+ expect(ctx.userInitiatedPropagationSync).toBe(false);
+ expect(ctx.propagationNodeStatus.state).toBe("idle");
+ expect(ctx.isSyncingPropagationNode).toBe(false);
+ });
});
diff --git a/tests/frontend/AppWsReconnectResync.test.js b/tests/frontend/AppWsReconnectResync.test.js
new file mode 100644
index 00000000..b12b463e
--- /dev/null
+++ b/tests/frontend/AppWsReconnectResync.test.js
@@ -0,0 +1,79 @@
+// SPDX-License-Identifier: 0BSD
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import App from "../../meshchatx/src/frontend/components/App.vue";
+
+vi.mock("../../meshchatx/src/frontend/js/csrfToken.js", () => ({
+ fetchCsrfToken: vi.fn(async () => "refreshed"),
+ getCsrfToken: vi.fn(() => "refreshed"),
+ setCsrfToken: vi.fn(),
+ clearCsrfToken: vi.fn(),
+}));
+
+import { fetchCsrfToken } from "../../meshchatx/src/frontend/js/csrfToken.js";
+import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
+
+describe("App websocket reconnect shell resync", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ function makeShellCtx(overrides = {}) {
+ return {
+ shellRunning: true,
+ wsDisconnected: true,
+ wsDisconnectedAt: Date.now() - 5000,
+ wsDisconnectedDurationText: "5s",
+ backendProcessExited: false,
+ backendExitCode: null,
+ wsDisconnectTickTimer: null,
+ wsReconnectedBanner: false,
+ wsReconnectedHideTimer: null,
+ getAppInfo: vi.fn(async () => {}),
+ getConfig: vi.fn(async () => {}),
+ getBlockedDestinations: vi.fn(async () => {}),
+ getKeyboardShortcuts: vi.fn(async () => {}),
+ updateRingtonePlayer: vi.fn(async () => {}),
+ updateTelephoneStatus: vi.fn(async () => {}),
+ updatePropagationNodeStatus: vi.fn(async () => {}),
+ resyncShellAfterWebsocketReconnect: App.methods.resyncShellAfterWebsocketReconnect,
+ onWsShellConnected: App.methods.onWsShellConnected,
+ ...overrides,
+ };
+ }
+
+ it("refreshes CSRF and shell status on reconnect after background stall recovery", async () => {
+ // Oracle: forceReconnect after a backgrounded tab must still run shell
+ // resync (isReconnect true) including CSRF refresh so Sync Messages POSTs work.
+ const emitSpy = vi.spyOn(GlobalEmitter, "emit");
+ const ctx = makeShellCtx();
+
+ await App.methods.onWsShellConnected.call(ctx, { isReconnect: true });
+
+ expect(ctx.wsDisconnected).toBe(false);
+ expect(fetchCsrfToken).toHaveBeenCalledTimes(1);
+ expect(ctx.updatePropagationNodeStatus).toHaveBeenCalled();
+ expect(ctx.getConfig).toHaveBeenCalled();
+ expect(emitSpy).toHaveBeenCalledWith("websocket-reconnected");
+ expect(ctx.wsReconnectedBanner).toBe(true);
+
+ emitSpy.mockRestore();
+ });
+
+ it("does not resync shell on the first websocket connect", async () => {
+ const emitSpy = vi.spyOn(GlobalEmitter, "emit");
+ const ctx = makeShellCtx({ wsDisconnected: false, wsDisconnectedAt: null });
+
+ await App.methods.onWsShellConnected.call(ctx, { isReconnect: false });
+
+ expect(fetchCsrfToken).not.toHaveBeenCalled();
+ expect(ctx.updatePropagationNodeStatus).not.toHaveBeenCalled();
+ expect(emitSpy).not.toHaveBeenCalledWith("websocket-reconnected");
+
+ emitSpy.mockRestore();
+ });
+});
diff --git a/tests/frontend/WebSocketConnection.test.js b/tests/frontend/WebSocketConnection.test.js
index 034971d8..6d619a38 100644
--- a/tests/frontend/WebSocketConnection.test.js
+++ b/tests/frontend/WebSocketConnection.test.js
@@ -308,7 +308,36 @@ describe("WebSocketConnection module", () => {
expect(disconnected).not.toHaveBeenCalled();
expect(connected).toHaveBeenCalledTimes(2);
- expect(connected.mock.calls[1][0]).toEqual({ isReconnect: false });
+ // Background-tab stale recovery must still tell the shell this is a
+ // reconnect so CSRF/config/status can resync (without flashing disconnect).
+ expect(connected.mock.calls[1][0]).toEqual({ isReconnect: true });
+
+ WebSocketConnection.destroy();
+ });
+
+ it("marks forced reconnect as isReconnect after a prior successful open (background-tab stall)", async () => {
+ const MockWS = makeWsImpl();
+ global.WebSocket = MockWS;
+
+ const { default: WebSocketConnection } = await import("../../meshchatx/src/frontend/js/WebSocketConnection.js");
+
+ const connected = vi.fn();
+ WebSocketConnection.on("connected", connected);
+
+ await WebSocketConnection.connect();
+ await vi.waitUntil(() => WebSocketConnection.ws?.readyState === MockWS.OPEN);
+ expect(connected.mock.calls[0][0]).toEqual({ isReconnect: false });
+
+ const firstWs = WebSocketConnection.ws;
+ // Simulate a zombie OPEN socket after the tab slept: readyState still
+ // OPEN, but no frames for longer than the ping interval.
+ WebSocketConnection._lastReceivedTime = Date.now() - 60000;
+ WebSocketConnection.forceReconnect();
+
+ await vi.waitUntil(() => WebSocketConnection.ws && WebSocketConnection.ws !== firstWs);
+ await vi.waitUntil(() => WebSocketConnection.ws.readyState === MockWS.OPEN);
+
+ expect(connected.mock.calls[1][0]).toEqual({ isReconnect: true });
WebSocketConnection.destroy();
});
diff --git a/tests/frontend/apiClientCsrfRecovery.test.js b/tests/frontend/apiClientCsrfRecovery.test.js
new file mode 100644
index 00000000..d33bb237
--- /dev/null
+++ b/tests/frontend/apiClientCsrfRecovery.test.js
@@ -0,0 +1,126 @@
+// SPDX-License-Identifier: 0BSD
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+describe("apiClient CSRF recovery", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ global.window = { location: { origin: "http://127.0.0.1:5173" } };
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ it("retries a mutating request once after refreshing a stale CSRF token", async () => {
+ // Oracle: after a backgrounded web tab, the in-memory CSRF token can
+ // disagree with the cookie session. A single 403 CSRF response must
+ // refresh the token and retry, not treat the call as an auth logout.
+ const { setCsrfToken, getCsrfToken } = await import("../../meshchatx/src/frontend/js/csrfToken.js");
+ setCsrfToken("stale-token");
+
+ const onAuthError = vi.fn();
+ const { createApiClient } = await import("../../meshchatx/src/frontend/js/apiClient.js");
+ const api = createApiClient({ onAuthError });
+
+ let postCalls = 0;
+ const fetchMock = vi.fn(async (url, init) => {
+ const path = String(url);
+ if (path.includes("/api/v1/auth/csrf") && (!init?.method || init.method === "GET")) {
+ return {
+ ok: true,
+ status: 200,
+ headers: new Headers({ "content-type": "application/json" }),
+ text: async () => JSON.stringify({ csrf_token: "fresh-token" }),
+ };
+ }
+ if (path.includes("/api/v1/destination/deadbeef/request-path") && init?.method === "POST") {
+ postCalls += 1;
+ const hdrs = init.headers instanceof Headers ? init.headers : new Headers(init.headers);
+ const token = hdrs.get("X-CSRF-Token");
+ if (token === "stale-token") {
+ return {
+ ok: false,
+ status: 403,
+ headers: new Headers({ "content-type": "application/json" }),
+ text: async () => JSON.stringify({ error: "Invalid or missing CSRF token" }),
+ };
+ }
+ if (token === "fresh-token") {
+ return {
+ ok: true,
+ status: 200,
+ headers: new Headers({ "content-type": "application/json" }),
+ text: async () => JSON.stringify({ message: "ok" }),
+ };
+ }
+ }
+ return {
+ ok: false,
+ status: 500,
+ headers: new Headers({ "content-type": "application/json" }),
+ text: async () => JSON.stringify({ error: "unexpected" }),
+ };
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ const result = await api.post("/api/v1/destination/deadbeef/request-path");
+
+ expect(result.data).toEqual({ message: "ok" });
+ expect(postCalls).toBe(2);
+ expect(getCsrfToken()).toBe("fresh-token");
+ expect(onAuthError).not.toHaveBeenCalled();
+ });
+
+ it("does not call onAuthError for CSRF 403 responses", async () => {
+ const { setCsrfToken } = await import("../../meshchatx/src/frontend/js/csrfToken.js");
+ setCsrfToken("stale-token");
+
+ const onAuthError = vi.fn();
+ const { createApiClient } = await import("../../meshchatx/src/frontend/js/apiClient.js");
+ const api = createApiClient({ onAuthError });
+
+ vi.stubGlobal("fetch", async () => ({
+ ok: false,
+ status: 403,
+ headers: new Headers({ "content-type": "application/json" }),
+ text: async () => JSON.stringify({ error: "Invalid or missing CSRF token" }),
+ }));
+
+ // Force refresh itself to fail so retry cannot succeed.
+ await expect(api.post("/api/v1/destination/deadbeef/request-path")).rejects.toMatchObject({
+ response: { status: 403 },
+ });
+ expect(onAuthError).not.toHaveBeenCalled();
+ });
+
+ it("still calls onAuthError for real auth 401 responses", async () => {
+ const { setCsrfToken } = await import("../../meshchatx/src/frontend/js/csrfToken.js");
+ setCsrfToken("good-token");
+
+ const onAuthError = vi.fn();
+ const { createApiClient } = await import("../../meshchatx/src/frontend/js/apiClient.js");
+ const api = createApiClient({ onAuthError });
+
+ vi.stubGlobal("fetch", async () => ({
+ ok: false,
+ status: 401,
+ headers: new Headers({ "content-type": "application/json" }),
+ text: async () => JSON.stringify({ error: "Authentication required" }),
+ }));
+
+ await expect(api.get("/api/v1/config")).rejects.toMatchObject({
+ response: { status: 401 },
+ });
+ expect(onAuthError).toHaveBeenCalledTimes(1);
+ });
+
+ it("classifies CSRF rejection bodies without treating other 403s as CSRF", async () => {
+ const { isCsrfRejection } = await import("../../meshchatx/src/frontend/js/apiClient.js");
+ expect(isCsrfRejection(403, { error: "Invalid or missing CSRF token" })).toBe(true);
+ expect(isCsrfRejection(403, { error: "Forbidden: client IP not on allowlist" })).toBe(false);
+ expect(isCsrfRejection(401, { error: "Invalid or missing CSRF token" })).toBe(false);
+ expect(isCsrfRejection(403, null)).toBe(false);
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────